Skip to content

helper-based atomic-write cleanup; replace @unlink/@chmod#52

Open
mambax7 wants to merge 11 commits intoXOOPS:masterfrom
mambax7:fix/atomic-write-cleanup-helpers
Open

helper-based atomic-write cleanup; replace @unlink/@chmod#52
mambax7 wants to merge 11 commits intoXOOPS:masterfrom
mambax7:fix/atomic-write-cleanup-helpers

Conversation

@mambax7
Copy link
Copy Markdown
Contributor

@mambax7 mambax7 commented May 7, 2026

Changes:
cp_functions.php:
+ xoops_remove_file_quietly($path, $context): new helper for best-effort file removal. Skips non-existent paths, suppresses the unlink warning via
a SCOPED error_reporting() toggle (no @ operator) wrapped in try/finally, and re-checks file_exists() after a failed unlink — only logging when the
file is still present so TOCTOU races resolve silently. Uses xoops_file_label() for non-sensitive path labels in warnings.
- 9 @Unlink cleanup sites replaced with the helper.
- @chmod($tempFile, $perms) replaced with checked chmod() that logs on failure but continues (content is already written).

modules/system/class/maintenance.php:
+ Explicit require_once .../include/cp_functions.php so SystemMaintenance is self-sufficient regardless of which caller (admin, install,
modulesadmin, preferences) loads it.
- 6 atomic-write @Unlink cleanup sites replaced with the helper.
- @chmod replaced with checked + warn (mirrors cp_functions.php).
- cleanOrphanedAvatars(): @Unlink replaced with the helper plus a path-traversal-safe resolution. Avatar rows store 'avatars/'
(kernel/avatar.php and 14 admin/profile writers), so basename() would have stripped the directory and silently bypassed all orphan cleanup. The new form
normalises backslashes, strips leading slashes, resolves via realpath() with upload-root containment, and confirms is_file() before removal. The DB row
cleanup runs unconditionally.

Intentionally retained:
- The 7 @rename(...) calls inside if (!...) checks. These are the core atomic-move operations; the boolean return is already detected and reported
via trigger_error(). Removing the @ would let PHP's native warning fire alongside our diagnostic, double-reporting one event into display_errors
output. Comment block in each file documents the rationale.

Summary by CodeRabbit

  • Bug Fixes

    • Improved atomic file writes and cleanup: quieter error reporting, non-fatal permission attempts, and reliable temporary/backup cleanup with safe restore on failure; added quiet helpers for file removal and chmod.
    • Safer avatar removal during maintenance: path normalization, resolved-path validation inside the avatars area, and best-effort quiet deletion while still cleaning DB links.
    • Ensured maintenance reliably loads required cleanup routines.
  • Tests

    • Added unit tests covering avatar cleanup, path traversal, missing files, null-byte and Windows-style paths.

Review Change Stack

Changes:

    cp_functions.php:
      + xoops_remove_file_quietly($path, $context): new helper for
        best-effort file removal. Skips non-existent paths, suppresses
        the unlink warning via a SCOPED error_reporting() toggle (no @
        operator) wrapped in try/finally, and re-checks file_exists()
        after a failed unlink — only logging when the file is still
        present so TOCTOU races resolve silently. Uses
        xoops_file_label() for non-sensitive path labels in warnings.
      - 9 @Unlink cleanup sites replaced with the helper.
      - @chmod($tempFile, $perms) replaced with checked chmod() that
        logs on failure but continues (content is already written).

    modules/system/class/maintenance.php:
      + Explicit `require_once .../include/cp_functions.php` so
        SystemMaintenance is self-sufficient regardless of which
        caller (admin, install, modulesadmin, preferences) loads it.
      - 6 atomic-write @Unlink cleanup sites replaced with the helper.
      - @chmod replaced with checked + warn (mirrors cp_functions.php).
      - cleanOrphanedAvatars(): @Unlink replaced with the helper plus
        a path-traversal-safe resolution. Avatar rows store
        'avatars/<filename>' (kernel/avatar.php and 14 admin/profile
        writers), so basename() would have stripped the directory and
        silently bypassed all orphan cleanup. The new form:
          - normalises backslashes to '/' (Windows-historic data)
          - strips leading slashes (defends against absolute paths)
          - resolves via realpath() so '../' segments collapse
          - confirms containment under realpath(XOOPS_UPLOAD_PATH)
            with a trailing-separator boundary check
          - confirms is_file() before removal.
        The DB row cleanup runs unconditionally (file gone or not).

  Intentionally retained:
    - The 7 @rename(...) calls inside `if (!...)` checks. These are
      the core atomic-move operations; the boolean return is already
      detected and reported via trigger_error(). Removing the @ would
      let PHP's native warning fire alongside our diagnostic, double-
      reporting one event into display_errors output. Comment block
      in each file documents the rationale.
Copilot AI review requested due to automatic review settings May 7, 2026 19:53
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 7, 2026

Important

Review skipped

This PR was authored by the user configured for CodeRabbit reviews. CodeRabbit does not review PRs authored by this user. It's recommended to use a dedicated user account to post CodeRabbit review feedback.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: f9c7e80d-0b7e-4bca-9637-fe2991699be3

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Walkthrough

Adds scoped quiet chmod/unlink helpers, applies them to atomic-write and guard-file flows, hardens avatar deletion with path normalization and containment checks, requires the helpers in SystemMaintenance, and adds unit tests covering CleanAvatar() variants.

Changes

File Handling Safety and Error Recovery

Layer / File(s) Summary
Quiet File Removal Utility
htdocs/include/cp_functions.php
New xoops_chmod_quietly($path, $perms, $context) and xoops_remove_file_quietly($path, $context) to perform scoped-suppressed chmod/unlink with single E_USER_WARNING emission on failure.
Atomic File Writing Updates
htdocs/include/cp_functions.php
xoops_write_file_atomically() replaces @unlink() with xoops_remove_file_quietly(), treats chmod() failures as non-fatal (logs a warning and continues), and updates rename/error branches to clean temp and backup artifacts quietly before emitting errors.
Maintenance Class Bootstrap
htdocs/modules/system/class/maintenance.php
SystemMaintenance now require_once 'include/cp_functions.php' to ensure helpers are available in all instantiation contexts.
Avatar Path Validation and Cleanup
htdocs/modules/system/class/maintenance.php
CleanAvatar() normalizes stored path (backslashes → slashes, strip leading /), resolves realpath(), enforces containment within XOOPS_UPLOAD_PATH/avatars, verifies regular-file or symlink status, and calls xoops_remove_file_quietly() when appropriate; database row cleanup remains unconditional.
Guard File Write and Backup Recovery
htdocs/modules/system/class/maintenance.php
writeFileWithWarning() refactored to use xoops_remove_file_quietly() for guard/temp cleanup, tolerate chmod() failures, and add explicit backup creation, validation, restore-attempts, and quiet cleanup on success/failure.
Unit Tests
tests/unit/htdocs/modules/system/SystemMaintenanceTest.php
Adds helpers and multiple tests for CleanAvatar() covering valid deletion, traversal/absolute-path protection, missing/empty values, subdir confinement, and Windows backslash normalization.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 63.16% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: replacing error-suppression operators (@unlink/@chmod) with helper-based approaches for atomic-write cleanup, which is the core objective.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


Comment @coderabbitai help to get the list of available commands and usage tips.

@codecov
Copy link
Copy Markdown

codecov Bot commented May 7, 2026

Codecov Report

❌ Patch coverage is 22.36842% with 59 lines in your changes missing coverage. Please review.
✅ Project coverage is 18.07%. Comparing base (47b528b) to head (c0cb1de).
⚠️ Report is 8 commits behind head on master.

Files with missing lines Patch % Lines
htdocs/include/cp_functions.php 0.00% 38 Missing ⚠️
htdocs/modules/system/class/maintenance.php 44.73% 21 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##             master      #52      +/-   ##
============================================
- Coverage     18.08%   18.07%   -0.02%     
- Complexity     7840     7854      +14     
============================================
  Files           665      665              
  Lines         42982    43133     +151     
============================================
+ Hits           7775     7797      +22     
- Misses        35207    35336     +129     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR reduces error-suppression usage in file cleanup paths by introducing a centralized “best-effort” delete helper and adopting it in atomic-write cleanup code, while also hardening orphaned-avatar deletion against path traversal.

Changes:

  • Added xoops_remove_file_quietly() in cp_functions.php and replaced multiple @unlink cleanup sites with the helper.
  • Replaced @chmod calls with checked chmod() that logs warnings on failure but continues.
  • Hardened SystemMaintenance::CleanAvatar() orphan cleanup by normalizing and resolving avatar paths and enforcing containment under XOOPS_UPLOAD_PATH.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
htdocs/modules/system/class/maintenance.php Requires cp_functions.php, adopts the new cleanup helper in atomic-write cleanup, and hardens orphaned avatar file removal with realpath() + containment checks.
htdocs/include/cp_functions.php Introduces xoops_remove_file_quietly() and replaces multiple @unlink/@chmod usages in xoops_write_file_atomically().

Comment thread htdocs/modules/system/class/maintenance.php Outdated
Comment thread htdocs/modules/system/class/maintenance.php Outdated
…out of loop

  Address PR XOOPS#52 follow-up review (Copilot non-blocking comments):

  modules/system/class/maintenance.php (CleanAvatar):
    realpath(XOOPS_UPLOAD_PATH) and the trailing-separator prefix are
    constant for the whole run, so computing them per row in the
    while-loop is wasted filesystem work on installations with large
    orphaned-avatar tables. Hoisted outside the loop into \$uploadRoot
    and \$uploadRootPrefix; the in-loop check now uses str_starts_with
    against the cached prefix.

  tests/unit/htdocs/modules/system/SystemMaintenanceTest.php:
    Added six #[Test] cases covering the path-traversal-safe orphan
    cleanup logic introduced when @Unlink() was replaced by
    xoops_remove_file_quietly():

      - cleanAvatarRemovesValidAvatarFileUnderUploadRoot — happy path
      - cleanAvatarSkipsTraversalPathButStillDeletesDbRow — ../ blocked
      - cleanAvatarSkipsAbsolutePathButStillDeletesDbRow — /etc/hosts
      - cleanAvatarHandlesMissingFileAndStillDeletesDbRow
      - cleanAvatarHandlesEmptyAvatarFileAndStillDeletesDbRow
      - cleanAvatarNormalisesBackslashesInAvatarFile

    Each test asserts exec() is called exactly twice (avatar DELETE +
    avatar_user_link cleanup) regardless of file removal outcome, so
    the 'DB rows deleted regardless of file outcome' invariant is
    codified rather than relying on inspection.

    Filesystem fixtures live in a unique scratch subdirectory under
    XOOPS_UPLOAD_PATH/avatars/_test_<pid>_<uniqid> with finally-block
    cleanup so the tests do not collide with each other or with
    anything else in the upload tree.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

Comment on lines +343 to +354
/**
* Stub the database mock so $db->query() returns a sentinel result and
* $db->fetchArray() yields the supplied avatar rows once, then false.
* Returns the mock for further expectations.
*/
private function stubAvatarSweep($db, array $rows): void
{
$db->method('query')->willReturn('mock_result');
$db->method('isResultSet')->willReturn(true);
$rows[] = false; // end-of-result sentinel
$db->method('fetchArray')->willReturnOnConsecutiveCalls(...$rows);
}
  CI run XOOPS#52 failed with 6 IncompatibleReturnValueException errors:
    Method exec may not return value of type int, its declared return type is \"bool\"

  XoopsMySQLDatabase::exec(string \$sql): bool returns true on success
  and false on failure (or false from PHP's mysqli_query for many DDL
  statements). The CleanAvatar() tests stubbed exec() with willReturn(1),
  which works under loose return-type checking but trips PHPUnit 11's
  strict return-type validation.

  Fix: all 6 exec() stubs use willReturn(true).
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@htdocs/modules/system/class/maintenance.php`:
- Line 200: The DELETE SQL concatenates $myrow['avatar_id'] directly into the
string; change the interpolation to cast the value to int before concatenation
to avoid injection/scan warnings—update the calls that use $this->db->exec(...)
with $this->db->prefix('avatar') and $myrow['avatar_id'] (the deletes at the
current line and the adjacent one around line 213) so the avatar_id is cast to
(int) (e.g., use (int)$myrow['avatar_id']) when building the WHERE clause.

In `@tests/unit/htdocs/modules/system/SystemMaintenanceTest.php`:
- Line 368: The mocks for XoopsDatabase::exec() use willReturn(1) which violates
exec()'s bool return type and causes PHPUnit IncompatibleReturnValueException;
locate every occurrence of $db->expects(...)->method('exec')->willReturn(1) in
the SystemMaintenanceTest cleanAvatar* tests and change willReturn(1) to
willReturn(true) (apply to all six instances where exec() is stubbed).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 3dc5b9f2-faa6-4f9e-b34c-1076d70a5758

📥 Commits

Reviewing files that changed from the base of the PR and between 1e64962 and b5fc7bf.

📒 Files selected for processing (2)
  • htdocs/modules/system/class/maintenance.php
  • tests/unit/htdocs/modules/system/SystemMaintenanceTest.php

Comment thread htdocs/modules/system/class/maintenance.php Outdated
Comment thread tests/unit/htdocs/modules/system/SystemMaintenanceTest.php Outdated
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.

Comment on lines +343 to +354
/**
* Stub the database mock so $db->query() returns a sentinel result and
* $db->fetchArray() yields the supplied avatar rows once, then false.
* Returns the mock for further expectations.
*/
private function stubAvatarSweep($db, array $rows): void
{
$db->method('query')->willReturn('mock_result');
$db->method('isResultSet')->willReturn(true);
$rows[] = false; // end-of-result sentinel
$db->method('fetchArray')->willReturnOnConsecutiveCalls(...$rows);
}
Comment thread tests/unit/htdocs/modules/system/SystemMaintenanceTest.php Outdated
Comment thread tests/unit/htdocs/modules/system/SystemMaintenanceTest.php Outdated
…st; (int) avatar_id

  Address PR XOOPS#52 follow-up review (Copilot, CodeRabbit Trivial):

  modules/system/class/maintenance.php (CleanAvatar):
    Cast $myrow['avatar_id'] to int once at the top of the loop body
    and use $avatarId in both DELETE statements (avatar row + the
    empty-avatar_file fast path). Defence-in-depth on the SQL concat
    even though the value is DB-origin; also silences SonarCloud's
    string-concatenation warning on these statements per project SQL
    hygiene.

  tests/unit/htdocs/modules/system/SystemMaintenanceTest.php:

    cleanAvatarSkipsTraversalPathButStillDeletesDbRow:
      The previous form created the fixture in sys_get_temp_dir() but
      used '../../<basename>' as the avatar_file. That string resolves
      from htdocs/uploads/ to a path under/near the project root —
      NOT the temp dir. realpath() returned false, the cleanup was
      skipped, and the test passed for the wrong reason: it never
      exercised the containment-prefix branch.

      New form: place the fixture at dirname(realpath(XOOPS_UPLOAD_PATH))
      so '../<basename>' actually resolves to it. realpath() now
      succeeds, and the prefix check is the rejection mechanism.
      Added an explicit assertSame() sanity assertion comparing the
      resolved path to realpath($outside) so the test self-documents
      that it really exercises the containment branch.

    cleanAvatarSkipsAbsolutePathButStillDeletesDbRow:
      Replaced the hard-coded /etc/hosts assertion with a temp fixture
      via tempnam(). Windows CI runners don't have /etc/hosts at the
      same path, and even on POSIX systems the test relied on a
      pre-existing system file. tempnam() works on every supported OS.

    stubAvatarSweep():
      Removed the misleading "Returns the mock for further expectations"
      docblock line. The method is `: void`; callers attach further
      expectations directly to the $db they passed in.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.

Comment thread htdocs/include/cp_functions.php Outdated
Comment thread htdocs/modules/system/class/maintenance.php Outdated
Comment on lines +203 to +217
$avatarId = (int) ($myrow['avatar_id'] ?? 0);
$avatarFile = ltrim(str_replace('\\', '/', (string) ($myrow['avatar_file'] ?? '')), '/');
if ('' === $avatarFile) {
$result1 = $this->db->exec('DELETE FROM ' . $this->db->prefix('avatar') . ' WHERE avatar_id=' . $avatarId);
continue;
}
$avatarPath = realpath(XOOPS_UPLOAD_PATH . '/' . $avatarFile);
if (
is_string($avatarPath)
&& is_string($uploadRootPrefix)
&& str_starts_with($avatarPath, $uploadRootPrefix)
&& is_file($avatarPath)
) {
xoops_remove_file_quietly($avatarPath, 'orphaned avatar');
}
  Address PR XOOPS#52 follow-up review (Copilot, 3 actionable):

  cp_functions.php:
    + xoops_chmod_quietly($path, $perms, $context): new helper
      mirroring xoops_remove_file_quietly(). Suppresses chmod()'s
      native PHP warning via the same scoped error_reporting() toggle
      + try/finally and emits a single project-standard trigger_error
      on the boolean false return. Without this, a single chmod
      failure produced TWO log lines (PHP warning + trigger_error).
    - The chmod() + trigger_error block in xoops_write_file_atomically()
      replaced with the helper.

  modules/system/class/maintenance.php:
    - chmod() + trigger_error block in writeFileWithWarning() replaced
      with the same helper (already require_once'ing cp_functions.php).
    - CleanAvatar(): containment check narrowed from
      realpath(XOOPS_UPLOAD_PATH) to realpath(XOOPS_UPLOAD_PATH .
      '/avatars'). Custom avatars are stored as 'avatars/<filename>'
      (kernel/avatar.php and 14 admin/edituser writers), so the broad
      upload-root check would have allowed deletion of any file under
      uploads/ if avatar_file pointed elsewhere — legacy data, custom-
      module write, or accidental insertion. The narrow prefix is
      defence-in-depth: skip rather than silently delete unrelated
      uploads. Hoisted out of the loop so the realpath() resolution
      is computed once per sweep.

  tests/unit/htdocs/modules/system/SystemMaintenanceTest.php:
    + cleanAvatarSkipsNonAvatarsSubdirUnderUploadRoot: places a
      fixture at uploads/files/<unique>.doc and sets avatar_file to
      'files/<unique>.doc'. Asserts the resolved path is under
      uploads/ (proving the test exercises the narrow-prefix branch,
      not the realpath-fail branch), runs CleanAvatar(), and verifies
      the fixture survives — codifying the narrowing as a regression
      test.

  No @ operators remain in either file's unlink/chmod paths; the
  seven @rename(...) calls inside `if (!...)` checks are still
  retained with the documented rationale.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

Comment thread tests/unit/htdocs/modules/system/SystemMaintenanceTest.php Outdated
…atar

  Address PR XOOPS#52 follow-up review (Copilot, line 316):

  placeFixtureAvatar() and the three inline file_put_contents() sites
  in cleanAvatar* tests previously ignored the return value. If the
  write failed (FS permissions, full disk, etc.) the happy-path tests
  could pass for the wrong reason — CleanAvatar() would find no file
  to delete and assertFileDoesNotExist() would succeed even though
  the cleanup never ran on a real fixture.

  All four fixture-creation sites now:
    - capture file_put_contents() return into $bytesWritten
    - assertNotFalse with a path-suffixed message for triage
    - assertSame(strlen(content), $bytesWritten) so partial writes
      are caught too, not just false-return failures

  Sites covered:
    - placeFixtureAvatar() (the one Copilot flagged)
    - cleanAvatarSkipsTraversalPathButStillDeletesDbRow
    - cleanAvatarSkipsAbsolutePathButStillDeletesDbRow
    - cleanAvatarSkipsNonAvatarsSubdirUnderUploadRoot
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
htdocs/modules/system/class/maintenance.php (1)

156-171: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add @throws \RuntimeException to the CleanAvatar() docblock.

The method throws a \RuntimeException (line 169-173) but the PHPDoc is silent about it. As per coding guidelines, public methods must declare @throws when applicable.

📝 Proposed docblock update
 /**
  * CleanAvatar
  *
  * Clean up orphaned custom avatars left when a user is deleted.
  *
  * `@author` slider84 of Team FrXoops
  *
+ * `@throws` \RuntimeException If the avatar table query fails.
  * `@return` boolean
  */

As per coding guidelines: "New public methods have PHPDoc with @param, @return, and @throws tags".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@htdocs/modules/system/class/maintenance.php` around lines 156 - 171, The
CleanAvatar() method can throw a \RuntimeException but its PHPDoc lacks a
`@throws` tag; update the method's docblock for CleanAvatar to include "@throws
\RuntimeException" (with a brief description) so the public method documents the
possible exception thrown by the database error branch (the RuntimeException
raised around the $this->db->error() call).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@htdocs/include/cp_functions.php`:
- Around line 138-187: Both xoops_chmod_quietly and xoops_remove_file_quietly
risk using an uninitialized $ok if a user error handler throws inside the try;
to fix, initialize $ok = false immediately before each try block (in
xoops_chmod_quietly and xoops_remove_file_quietly) so the subsequent if (!$ok)
checks are safe even when unlink()/chmod() never return due to an exception;
keep the scoped error_reporting() and existing trigger_error() behavior
unchanged.

In `@htdocs/modules/system/class/maintenance.php`:
- Around line 210-227: The two assignments to $result1 in the CleanAvatar method
are unused; remove the variable capture and call $this->db->exec(...) directly
(or alternatively check its boolean result and log/return false on failure).
Locate the two calls to $this->db->exec(...) that currently assign to $result1
in class Maintenance::CleanAvatar and either (A) replace " $result1 =
$this->db->exec(...);" with "$this->db->exec(...);" to make the intent explicit,
or (B) change to "if (!$this->db->exec(...)) { /* log via
processLogger/db->error and return false; */ }" so failures are propagated.

---

Outside diff comments:
In `@htdocs/modules/system/class/maintenance.php`:
- Around line 156-171: The CleanAvatar() method can throw a \RuntimeException
but its PHPDoc lacks a `@throws` tag; update the method's docblock for CleanAvatar
to include "@throws \RuntimeException" (with a brief description) so the public
method documents the possible exception thrown by the database error branch (the
RuntimeException raised around the $this->db->error() call).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 663a51de-03a6-4402-a4f1-f40999eb6701

📥 Commits

Reviewing files that changed from the base of the PR and between b5fc7bf and 1f4622c.

📒 Files selected for processing (3)
  • htdocs/include/cp_functions.php
  • htdocs/modules/system/class/maintenance.php
  • tests/unit/htdocs/modules/system/SystemMaintenanceTest.php

Comment thread htdocs/include/cp_functions.php
Comment thread htdocs/modules/system/class/maintenance.php
  Address PR XOOPS#52 follow-up review (CodeRabbit, 3 findings):

  cp_functions.php (xoops_chmod_quietly, xoops_remove_file_quietly):
    Initialise $ok = false before each try block. error_reporting(0)
    does NOT disable user-defined error handlers; only the native
    warning. A handler that throws (e.g. a strict ErrorException
    conversion that doesn't check error_reporting() & $errno) would
    propagate out of the try block before chmod()/unlink() returns,
    leaving $ok unset and triggering an "Undefined variable" warning
    on the subsequent if (!$ok) check. The defensive default makes
    the helpers safe under any error-handler shape.

  modules/system/class/maintenance.php (CleanAvatar):
    Drop the unused `$result1 = ` capture on both $this->db->exec()
    calls. PHPMD flagged this; the value was never read so the
    assignment was misleading (no error propagation either way).
    Removing makes the discard explicit.

    Also added `@throws \RuntimeException` to the CleanAvatar() docblock
    per project convention — the method does throw RuntimeException
    when the avatar query fails, but the PHPDoc was silent about it.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

Comment on lines +22 to +27
// xoops_remove_file_quietly() lives in cp_functions.php; admin and install
// callers normally load it via cp_header.php / page_moduleinstaller.php,
// but require it explicitly here so SystemMaintenance is self-sufficient
// regardless of which context instantiates it.
require_once XOOPS_ROOT_PATH . '/include/cp_functions.php';

Comment on lines +176 to +192
function xoops_remove_file_quietly($path, $context = 'temporary')
{
if (!file_exists($path)) {
return;
}
// Initialise $ok defensively — see xoops_chmod_quietly() for the
// rationale (error_reporting(0) does not disable user-defined
// error handlers).
$ok = false;
$previousLevel = error_reporting(0);
try {
$ok = unlink($path);
} finally {
error_reporting($previousLevel);
}
if (!$ok && file_exists($path)) {
trigger_error(
  Address PR XOOPS#52 follow-up review (Copilot, 2 findings — CodeRabbit APPROVED):

  modules/system/class/maintenance.php:
    Add a `defined('XOOPS_ROOT_PATH') || exit()` guard before the
    require_once. The previous code commented out the guard but
    immediately uses XOOPS_ROOT_PATH in require_once, so direct
    access (or any include before bootstrap) would fail with an
    undefined-constant fatal that leaks server path detail in the
    error message. The guard makes direct access a clean exit.

  include/cp_functions.php (xoops_remove_file_quietly):
    file_exists() returns false for broken symlinks, so a dangling
    symlink would skip both the early-return guard AND the
    post-unlink existence check — the cleanup helper would never
    attempt removal and never warn that it was left behind. Add
    `|| is_link($path)` to both checks: unlink() can remove broken
    symlinks just fine, and the targets they point to are not what
    this helper is responsible for.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.

Comment thread htdocs/modules/system/class/maintenance.php Outdated
Comment thread htdocs/modules/system/class/maintenance.php Outdated
Comment thread htdocs/modules/system/class/maintenance.php Outdated
Comment thread htdocs/modules/system/class/maintenance.php Outdated
… guard

  Address PR XOOPS#52 follow-up review (Copilot, 4 findings):

  modules/system/class/maintenance.php:

    Direct-access guard (line 24):
      Switched from `if (!defined(...)) { exit(); }` to the project-
      standard one-liner:
        defined('XOOPS_ROOT_PATH') || exit('Restricted access');
      Consistent with the other guards across the codebase, and the
      'Restricted access' message is informative for log triage.

    CleanAvatar (symlink safety, lines 232-241):
      Previous code called realpath() on the full avatar path, then
      used the resolved path for both containment validation AND for
      the unlink. That's unsafe: realpath() follows symlinks, so an
      avatar entry that's a symlink within uploads/avatars/ would
      resolve to its target and the unlink would delete the wrong
      file (potentially another avatar) while leaving the symlink in
      place.

      New form keeps two paths:
        - $avatarCandidate = original path (used for is_file/is_link
          and the unlink itself — operates on the symlink, not the
          target)
        - $avatarParent    = realpath(dirname($candidate))
          (used ONLY for containment validation — resolves the
          parent directory's location without resolving the symlink
          itself)

      is_file() also gained an `|| is_link()` so symlinks within the
      avatars/ subtree are now eligible for cleanup.

      Smoke-tested: a symlink at uploads/avatars/<link> pointing to
      uploads/avatars/<real> is now correctly cleaned up (the
      symlink is removed, the target survives).

    PHPDoc (line 167):
      @return boolean → @return bool for consistency with the rest of
      the file.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
htdocs/modules/system/class/maintenance.php (1)

278-358: 🧹 Nitpick | 🔵 Trivial | 💤 Low value

writeFileWithWarning() atomic-write paths are consistent with the new helper conventions.

Helper routing for temp-guard and backup-guard cleanup is uniform, the chmod demotion to non-fatal matches xoops_write_file_atomically() semantics, and the $backupFile = null initialisation at Line 325 cleanly disambiguates the "no prior file" path from the "restoration needed" path at Lines 347–351.

One minor observability nit (not blocking): when the initial replace fails but @rename($backupFile, $filename) succeeds, the user sees only Failed to replace guard file, with no indication that the original was restored. Tracking it as something to consider if the message ever surfaces in support tickets.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@htdocs/modules/system/class/maintenance.php` around lines 278 - 358, The
failure path in writeFileWithWarning() currently logs only "Failed to replace
guard file" when rename(temp->target) fails even if the subsequent restore of
the backup ($backupFile -> $filename) succeeds; update writeFileWithWarning() so
that inside the if (!@rename($tempFile, $filename)) { ... if ($backupFile !==
null) { if (!@rename($backupFile, $filename)) { ... } } } block you detect the
return of `@rename`($backupFile, $filename) and emit an additional, clear log when
the restore succeeds (e.g. a trigger_error/notice like "Restored original guard
file: %s" using $label) so callers can observe that the original was restored;
keep the existing error path when the restore fails.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@htdocs/modules/system/class/maintenance.php`:
- Around line 278-358: The failure path in writeFileWithWarning() currently logs
only "Failed to replace guard file" when rename(temp->target) fails even if the
subsequent restore of the backup ($backupFile -> $filename) succeeds; update
writeFileWithWarning() so that inside the if (!@rename($tempFile, $filename)) {
... if ($backupFile !== null) { if (!@rename($backupFile, $filename)) { ... } }
} block you detect the return of `@rename`($backupFile, $filename) and emit an
additional, clear log when the restore succeeds (e.g. a trigger_error/notice
like "Restored original guard file: %s" using $label) so callers can observe
that the original was restored; keep the existing error path when the restore
fails.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 1d53766d-6c3d-48ac-8b53-1513a1b1f78a

📥 Commits

Reviewing files that changed from the base of the PR and between 87e6cc5 and efc6796.

📒 Files selected for processing (2)
  • htdocs/include/cp_functions.php
  • htdocs/modules/system/class/maintenance.php

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (1)

htdocs/modules/system/class/maintenance.php:248

  • CleanAvatar() always returns true and ignores the return values from the DELETE exec() calls (including the final avatar_user_link cleanup, currently assigned to $result2 but never checked). Since callers use the boolean return to decide whether the avatar purge succeeded (e.g. system/admin/maintenance/main.php), failures here will be reported as success. Consider tracking exec() results and returning false when a delete fails (or removing the boolean return entirely if you don't want callers to rely on it).
            $this->db->exec('DELETE FROM ' . $this->db->prefix('avatar') . ' WHERE avatar_id=' . $avatarId);
        }
        //clean any deleted users from avatar_user_link table
        $result2 = $this->db->exec('DELETE FROM ' . $this->db->prefix('avatar_user_link') . ' WHERE user_id NOT IN (SELECT uid FROM ' . $this->db->prefix('users') . ')');

        return true;

… writeFileWithWarning

  modules/system/class/maintenance.php:

    CleanAvatar() — return contract:
      The method's @return bool was always returning true regardless
      of whether any DELETE actually succeeded. If a row delete or
      the final avatar_user_link cleanup failed, the orphaned row
      was left behind and the caller had no way to know.

      Track a $deleteOk flag across all three exec() sites
      (empty-avatar_file branch, regular avatar-row branch, final
      avatar_user_link cleanup) and return it. Existing CleanAvatar
      tests stub exec() with willReturn(true) so $deleteOk stays
      true and the return value is unchanged in the happy path.

    writeFileWithWarning() — restore observability:
      When the rename($tempFile, $filename) replace step fails but
      the rename($backupFile, $filename) restore step succeeds, the
      log previously said only "Failed to replace guard file: <X>",
      leaving operators unsure whether manual recovery was needed.

      Track $restoredBackup and fold the status into the existing
      composite failure warning:
        "Failed to replace guard file: <X> (original restored)"
      when the restore worked, or the unsuffixed form (plus the
      pre-existing "Failed to restore original guard file" line)
      when both steps failed and manual intervention is needed.
      No additional success warnings introduced.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

Comment on lines +138 to +202
function xoops_chmod_quietly($path, $perms, $context = 'temp')
{
// Initialise $ok before the try block: error_reporting(0) does NOT
// disable user-defined error handlers, only the native warning. A
// naively written handler that always throws (without checking
// error_reporting() & $errno) would propagate out of the try block
// before chmod() returns, leaving $ok unset. Defensive default.
$ok = false;
$previousLevel = error_reporting(0);
try {
$ok = chmod($path, $perms);
} finally {
error_reporting($previousLevel);
}
if (!$ok) {
trigger_error(
sprintf('Failed to set permissions on %s file: %s', $context, xoops_file_label($path)),
E_USER_WARNING
);
}

return $ok;
}

/**
* Best-effort file removal used by atomic-write cleanup paths and similar
* fire-and-forget cleanup. Skips non-existent paths so already-deleted
* files don't trigger warnings, suppresses the unlink() warning via a
* scoped error_reporting() toggle (no `@` operator), and re-checks
* existence after a failed unlink — only logging when the file is still
* present, so TOCTOU races resolve silently.
*
* @param string $path Absolute path to the file to remove.
* @param string $context Short label used in the warning message
* (e.g. 'temporary', 'backup').
*
* @return void
*/
function xoops_remove_file_quietly($path, $context = 'temporary')
{
// file_exists() returns false for broken symlinks, so a dangling
// symlink would be skipped here and also bypass the post-unlink
// existence check below — leaving the orphaned link in place. Treat
// links as existing too: unlink() can remove broken symlinks just
// fine, and the targets they point to are not what we care about.
if (!file_exists($path) && !is_link($path)) {
return;
}
// Initialise $ok defensively — see xoops_chmod_quietly() for the
// rationale (error_reporting(0) does not disable user-defined
// error handlers).
$ok = false;
$previousLevel = error_reporting(0);
try {
$ok = unlink($path);
} finally {
error_reporting($previousLevel);
}
if (!$ok && (file_exists($path) || is_link($path))) {
trigger_error(
sprintf('Failed to remove %s file: %s', $context, xoops_file_label($path)),
E_USER_WARNING
);
}
}
Comment on lines +233 to +250
$avatarId = (int) ($myrow['avatar_id'] ?? 0);
$avatarFile = ltrim(str_replace('\\', '/', (string) ($myrow['avatar_file'] ?? '')), '/');
if ('' === $avatarFile) {
if (!$this->db->exec('DELETE FROM ' . $this->db->prefix('avatar') . ' WHERE avatar_id=' . $avatarId)) {
$deleteOk = false;
}
continue;
}
$avatarCandidate = XOOPS_UPLOAD_PATH . '/' . $avatarFile;
$avatarParent = realpath(dirname($avatarCandidate));
if (
is_string($avatarParent)
&& is_string($avatarRootPrefix)
&& str_starts_with(rtrim($avatarParent, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR, $avatarRootPrefix)
&& (is_file($avatarCandidate) || is_link($avatarCandidate))
) {
xoops_remove_file_quietly($avatarCandidate, 'orphaned avatar');
}
…ll-byte paths

  xoops_chmod_quietly() / xoops_remove_file_quietly(): wrap the fs syscall
  (and, in the remove helper, the file_exists()/is_link() probes) in
  catch(\Throwable) so a ValueError from a "\0"-bearing path — or a
  throwing user error handler — cannot abort the caller's unrelated work.

  CleanAvatar(): gate dirname/realpath/is_file/is_link/remove on
  "\0" not in normalized avatar_file; collapse the empty-string special
  branch into the same condition. Per-row and avatar_user_link DELETEs
  still run unconditionally so a malformed path never blocks reclaiming
  the orphaned row.

  Tests: add cleanAvatarHandlesNullByteAvatarFileAndStillDeletesDbRow,
  covering the ValueError-skipped path with both DELETEs asserted.
@sonarqubecloud
Copy link
Copy Markdown

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@htdocs/include/cp_functions.php`:
- Around line 159-163: The trigger_error calls currently expose root-relative
paths by using xoops_file_label($path); change those warning messages to include
only the basename of the path (use basename($path) or an equivalent safe helper)
instead of xoops_file_label($path) in the trigger_error calls (the ones invoking
trigger_error(sprintf('Failed to set permissions on %s file: %s', $context,
xoops_file_label($path)), E_USER_WARNING) and the similar block at the other
occurrence), so non-fatal filesystem warnings log only the filename and not the
full install layout.

In `@tests/unit/htdocs/modules/system/SystemMaintenanceTest.php`:
- Around line 366-592: The tests exercise filesystem branches but never assert
the new boolean return from CleanAvatar(); update each existing test that calls
$maintenance->CleanAvatar() to assert the returned bool (e.g.
$this->assertTrue($maintenance->CleanAvatar(), ...)) and add one new unit test
that uses createMockDatabase()/stubAvatarSweep() and sets
$db->expects(...)->method('exec')->willReturnOnConsecutiveCalls(true, false) (or
otherwise simulate a DELETE failure) then
assertFalse($maintenance->CleanAvatar()) to cover the failing-exec path;
reference the CleanAvatar() call, createMockDatabase(), stubAvatarSweep(), and
the mock exec() expectations to locate where to add these assertions.
- Around line 366-592: Rename each PHPUnit test method to the test... convention
so PHPUnit 9.6 discovers them: change
cleanAvatarRemovesValidAvatarFileUnderUploadRoot() →
testCleanAvatarRemovesValidAvatarFileUnderUploadRoot(),
cleanAvatarSkipsTraversalPathButStillDeletesDbRow() →
testCleanAvatarSkipsTraversalPathButStillDeletesDbRow(),
cleanAvatarSkipsAbsolutePathButStillDeletesDbRow() →
testCleanAvatarSkipsAbsolutePathButStillDeletesDbRow(),
cleanAvatarHandlesMissingFileAndStillDeletesDbRow() →
testCleanAvatarHandlesMissingFileAndStillDeletesDbRow(),
cleanAvatarHandlesEmptyAvatarFileAndStillDeletesDbRow() →
testCleanAvatarHandlesEmptyAvatarFileAndStillDeletesDbRow(),
cleanAvatarHandlesNullByteAvatarFileAndStillDeletesDbRow() →
testCleanAvatarHandlesNullByteAvatarFileAndStillDeletesDbRow(),
cleanAvatarSkipsNonAvatarsSubdirUnderUploadRoot() →
testCleanAvatarSkipsNonAvatarsSubdirUnderUploadRoot(), and
cleanAvatarNormalisesBackslashesInAvatarFile() →
testCleanAvatarNormalisesBackslashesInAvatarFile(); keep the existing #[Test]
attributes or remove them if you prefer, but the critical change is renaming the
methods so PHPUnit 9.6 recognizes the tests.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: fc364852-5811-4398-a9fd-1bae3f576321

📥 Commits

Reviewing files that changed from the base of the PR and between 87e6cc5 and c0cb1de.

📒 Files selected for processing (3)
  • htdocs/include/cp_functions.php
  • htdocs/modules/system/class/maintenance.php
  • tests/unit/htdocs/modules/system/SystemMaintenanceTest.php

Comment on lines +159 to +163
if (!$ok) {
trigger_error(
sprintf('Failed to set permissions on %s file: %s', $context, xoops_file_label($path)),
E_USER_WARNING
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Use basename-only labels in these warnings.

These helper warnings still surface root-relative paths via xoops_file_label(). The repo rule for non-fatal filesystem warnings is stricter: log only the basename so cleanup failures do not disclose install layout.

As per coding guidelines: "Use trigger_error() with E_USER_WARNING for non-fatal failures. Use basename() in error messages to avoid exposing server paths."

Also applies to: 223-226

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@htdocs/include/cp_functions.php` around lines 159 - 163, The trigger_error
calls currently expose root-relative paths by using xoops_file_label($path);
change those warning messages to include only the basename of the path (use
basename($path) or an equivalent safe helper) instead of xoops_file_label($path)
in the trigger_error calls (the ones invoking trigger_error(sprintf('Failed to
set permissions on %s file: %s', $context, xoops_file_label($path)),
E_USER_WARNING) and the similar block at the other occurrence), so non-fatal
filesystem warnings log only the filename and not the full install layout.

Comment on lines +366 to +592
#[Test]
public function cleanAvatarRemovesValidAvatarFileUnderUploadRoot(): void
{
$scratchRel = $this->avatarScratchRel();
[$rel, $abs] = $this->placeFixtureAvatar($scratchRel, 'foo.png');

$db = $this->createMockDatabase();
$this->stubAvatarSweep($db, [
['avatar_id' => 42, 'avatar_file' => $rel],
]);
// exec() called twice: once for DELETE FROM avatar, once for the
// avatar_user_link cleanup at the end of CleanAvatar().
$db->expects($this->exactly(2))->method('exec')->willReturn(true);

$maintenance = $this->createMaintenance($db);
try {
$maintenance->CleanAvatar();
$this->assertFileDoesNotExist($abs, 'fixture avatar should have been removed');
} finally {
$this->removeScratchDir($scratchRel);
}
}

#[Test]
public function cleanAvatarSkipsTraversalPathButStillDeletesDbRow(): void
{
// To meaningfully exercise the upload-root containment check the
// fixture has to live at the location the traversal string
// ACTUALLY resolves to. dirname(realpath(XOOPS_UPLOAD_PATH)) is
// the parent of the upload root, so a fixture placed there is
// reachable via '../<basename>' relative to XOOPS_UPLOAD_PATH.
// Result:
// - realpath() succeeds (the file exists at the parent dir)
// - the prefix check rejects it (not under uploads/)
// - the fixture survives — exercising the containment branch
// Without this setup, realpath() would just return false on a
// non-existent target and the test would pass for the wrong
// reason.
$uploadRoot = realpath(XOOPS_UPLOAD_PATH);
$this->assertIsString($uploadRoot, 'XOOPS_UPLOAD_PATH must resolve via realpath()');
$outside = dirname($uploadRoot) . DIRECTORY_SEPARATOR . 'xoops_avatar_traversal_target_' . uniqid() . '.png';
$bytesWritten = file_put_contents($outside, 'must-not-be-removed');
$this->assertNotFalse($bytesWritten, 'Could not write traversal fixture: ' . $outside);
$this->assertSame(strlen('must-not-be-removed'), $bytesWritten);
$traversalRel = '../' . basename($outside);

$db = $this->createMockDatabase();
$this->stubAvatarSweep($db, [
['avatar_id' => 99, 'avatar_file' => $traversalRel],
]);
// DB row + avatar_user_link cleanup still execute.
$db->expects($this->exactly(2))->method('exec')->willReturn(true);

$maintenance = $this->createMaintenance($db);
try {
// Sanity: realpath of the traversal path resolves to the
// fixture (i.e. realpath() succeeds) — proving this test
// really exercises the prefix-check branch and not the
// realpath()-returns-false short-circuit.
$resolved = realpath(XOOPS_UPLOAD_PATH . '/' . $traversalRel);
$this->assertSame(realpath($outside), $resolved, 'traversal must actually resolve to the fixture');

$maintenance->CleanAvatar();
$this->assertFileExists($outside, 'traversal target outside upload root must not be removed');
} finally {
@unlink($outside);
}
}

#[Test]
public function cleanAvatarSkipsAbsolutePathButStillDeletesDbRow(): void
{
// An absolute path stored in avatar_file should not allow the
// cleanup to escape XOOPS_UPLOAD_PATH. Use a temp fixture rather
// than hard-coding /etc/hosts so the test runs on every OS
// (Windows CI doesn't have /etc/hosts at the same location).
// The ltrim('/') step turns '/abs/path/foo.png' into
// 'abs/path/foo.png' which is then resolved relative to the
// upload root — typically to a non-existent path, so realpath()
// returns false and the cleanup is skipped.
$outside = tempnam(sys_get_temp_dir(), 'xoops_avatar_absolute_');
$this->assertNotFalse($outside, 'tempnam should succeed');
$bytesWritten = file_put_contents($outside, 'must-not-be-removed');
$this->assertNotFalse($bytesWritten, 'Could not write absolute-path fixture: ' . $outside);
$this->assertSame(strlen('must-not-be-removed'), $bytesWritten);

$db = $this->createMockDatabase();
$this->stubAvatarSweep($db, [
['avatar_id' => 100, 'avatar_file' => $outside],
]);
$db->expects($this->exactly(2))->method('exec')->willReturn(true);

$maintenance = $this->createMaintenance($db);
try {
$maintenance->CleanAvatar();
$this->assertFileExists($outside, 'absolute-path fixture must not be removed by avatar cleanup');
} finally {
@unlink($outside);
}
}

#[Test]
public function cleanAvatarHandlesMissingFileAndStillDeletesDbRow(): void
{
$db = $this->createMockDatabase();
$this->stubAvatarSweep($db, [
// File reference that does not exist on disk — realpath() will
// return false. The DB row cleanup must still run.
['avatar_id' => 200, 'avatar_file' => 'avatars/nonexistent_' . uniqid() . '.png'],
]);
$db->expects($this->exactly(2))->method('exec')->willReturn(true);

$maintenance = $this->createMaintenance($db);
$maintenance->CleanAvatar(); // assertion is the exec() call count
}

#[Test]
public function cleanAvatarHandlesEmptyAvatarFileAndStillDeletesDbRow(): void
{
$db = $this->createMockDatabase();
$this->stubAvatarSweep($db, [
['avatar_id' => 300, 'avatar_file' => ''],
]);
$db->expects($this->exactly(2))->method('exec')->willReturn(true);

$maintenance = $this->createMaintenance($db);
$maintenance->CleanAvatar();
}

#[Test]
public function cleanAvatarHandlesNullByteAvatarFileAndStillDeletesDbRow(): void
{
// On PHP 8+ dirname()/realpath()/is_file()/is_link() raise
// ValueError when the argument contains "\0". CleanAvatar() must
// skip the filesystem work for such a row but still issue both
// DELETEs — the per-row avatar DELETE and the trailing
// avatar_user_link cleanup — so the malformed row doesn't block
// the rest of the sweep.
$db = $this->createMockDatabase();
$this->stubAvatarSweep($db, [
['avatar_id' => 400, 'avatar_file' => "avatars/evil\0.png"],
]);
$db->expects($this->exactly(2))->method('exec')->willReturn(true);

$maintenance = $this->createMaintenance($db);
$maintenance->CleanAvatar();
}

#[Test]
public function cleanAvatarSkipsNonAvatarsSubdirUnderUploadRoot(): void
{
// Defence-in-depth: even an avatar_file value that points to a
// legitimate path UNDER XOOPS_UPLOAD_PATH but OUTSIDE the
// avatars/ subtree must not be removed by the avatar sweep.
// Place a fixture at uploads/files/<unique>.doc, set avatar_file
// to that path, verify the file survives.
$filesDir = XOOPS_UPLOAD_PATH . '/files';
$created = false;
if (!is_dir($filesDir)) {
if (!mkdir($filesDir, 0755, true) && !is_dir($filesDir)) {
$this->fail('Could not create test files dir: ' . $filesDir);
}
$created = true;
}
$fixtureName = '_test_nonavatar_' . getmypid() . '_' . uniqid() . '.doc';
$fixturePath = $filesDir . '/' . $fixtureName;
$bytesWritten = file_put_contents($fixturePath, 'must-not-be-removed');
$this->assertNotFalse($bytesWritten, 'Could not write non-avatar fixture: ' . $fixturePath);
$this->assertSame(strlen('must-not-be-removed'), $bytesWritten);
$rel = 'files/' . $fixtureName;

$db = $this->createMockDatabase();
$this->stubAvatarSweep($db, [
['avatar_id' => 500, 'avatar_file' => $rel],
]);
$db->expects($this->exactly(2))->method('exec')->willReturn(true);

$maintenance = $this->createMaintenance($db);
try {
// Sanity: the resolved path IS under uploads/ but NOT under
// uploads/avatars/, so the broad upload-root check would
// have allowed deletion. Asserting the resolution succeeds
// proves the test really exercises the narrow prefix branch.
$resolved = realpath($fixturePath);
$this->assertIsString($resolved, 'fixture should resolve');
$this->assertStringStartsWith(
rtrim((string) realpath(XOOPS_UPLOAD_PATH), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR,
$resolved,
'fixture must be inside uploads/'
);

$maintenance->CleanAvatar();
$this->assertFileExists($fixturePath, 'non-avatar file under uploads/ must not be removed');
} finally {
@unlink($fixturePath);
if ($created) {
@rmdir($filesDir);
}
}
}

#[Test]
public function cleanAvatarNormalisesBackslashesInAvatarFile(): void
{
// Windows-historic data may store 'avatars\foo.png'. The cleanup
// should normalise it and remove the file under XOOPS_UPLOAD_PATH.
$scratchRel = $this->avatarScratchRel();
[$rel, $abs] = $this->placeFixtureAvatar($scratchRel, 'win.png');
// Replace forward slashes with backslashes only in the segment
// separator, NOT inside the scratch directory name (it has '_'
// not '\\'). This is what a Windows-saved row looked like.
$winRel = str_replace('/', '\\', $rel);

$db = $this->createMockDatabase();
$this->stubAvatarSweep($db, [
['avatar_id' => 400, 'avatar_file' => $winRel],
]);
$db->expects($this->exactly(2))->method('exec')->willReturn(true);

$maintenance = $this->createMaintenance($db);
try {
$maintenance->CleanAvatar();
$this->assertFileDoesNotExist($abs, 'backslash-normalised avatar should be removed');
} finally {
$this->removeScratchDir($scratchRel);
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Add an assertion for CleanAvatar()’s new boolean contract.

This block exercises the filesystem branches well, but it never checks the new bool return that now signals DELETE failures. A single failing-exec() case would lock in the PR’s main behavioral change.

➕ Suggested coverage
+    public function testCleanAvatarReturnsFalseWhenAvatarDeleteFails(): void
+    {
+        $db = $this->createMockDatabase();
+        $this->stubAvatarSweep($db, [
+            ['avatar_id' => 42, 'avatar_file' => ''],
+        ]);
+        $db->expects($this->exactly(2))
+            ->method('exec')
+            ->willReturnOnConsecutiveCalls(false, true);
+
+        $maintenance = $this->createMaintenance($db);
+        $this->assertFalse($maintenance->CleanAvatar());
+    }
🧰 Tools
🪛 PHPMD (2.15.0)

[warning] 372-372: Avoid variables with short names like $db. Configured minimum length is 3. (undefined)

(ShortVariable)


[error] 390-433: Remove error control operator '@' on line 431. (undefined)

(ErrorControlOperator)


[warning] 412-412: Avoid variables with short names like $db. Configured minimum length is 3. (undefined)

(ShortVariable)


[error] 436-465: Remove error control operator '@' on line 463. (undefined)

(ErrorControlOperator)


[warning] 452-452: Avoid variables with short names like $db. Configured minimum length is 3. (undefined)

(ShortVariable)


[warning] 470-470: Avoid variables with short names like $db. Configured minimum length is 3. (undefined)

(ShortVariable)


[warning] 485-485: Avoid variables with short names like $db. Configured minimum length is 3. (undefined)

(ShortVariable)


[warning] 504-504: Avoid variables with short names like $db. Configured minimum length is 3. (undefined)

(ShortVariable)


[error] 515-565: Remove error control operator '@' on line 560. (undefined)

(ErrorControlOperator)


[error] 515-565: Remove error control operator '@' on line 562. (undefined)

(ErrorControlOperator)


[warning] 537-537: Avoid variables with short names like $db. Configured minimum length is 3. (undefined)

(ShortVariable)


[warning] 579-579: Avoid variables with short names like $db. Configured minimum length is 3. (undefined)

(ShortVariable)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit/htdocs/modules/system/SystemMaintenanceTest.php` around lines 366
- 592, The tests exercise filesystem branches but never assert the new boolean
return from CleanAvatar(); update each existing test that calls
$maintenance->CleanAvatar() to assert the returned bool (e.g.
$this->assertTrue($maintenance->CleanAvatar(), ...)) and add one new unit test
that uses createMockDatabase()/stubAvatarSweep() and sets
$db->expects(...)->method('exec')->willReturnOnConsecutiveCalls(true, false) (or
otherwise simulate a DELETE failure) then
assertFalse($maintenance->CleanAvatar()) to cover the failing-exec path;
reference the CleanAvatar() call, createMockDatabase(), stubAvatarSweep(), and
the mock exec() expectations to locate where to add these assertions.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Does PHPUnit 9.6 support the PHPUnit\Framework\Attributes\TestandCoversClassattributes, or should tests keeptest... method names / annotations for cross-version compatibility?

💡 Result:

No, PHPUnit 9.6 does not support the PHPUnit\Framework\Attributes\Test or CoversClass attributes. These PHP 8 attributes were introduced in PHPUnit 10.[1][2][3] The official PHPUnit 9.6 manual documents only annotations (e.g., @test, @covers, @coversClass) for marking test methods and code coverage, with no mention of attributes.[4][5][6] PHPUnit 9.6 changelogs also lack any reference to attribute support.[7][8][9] PHPUnit 10 explicitly adds support for attributes as an alternative to annotations, checking attributes first and falling back to annotations if absent.[1][3][10] For cross-version compatibility (especially with PHPUnit 9.x), tests should continue using test... method name prefixes or annotation equivalents like @test and @coversClass / @coversDefaultClass. Attributes will cause errors in 9.6, as the PHPUnit\Framework\Attributes classes do not exist there.[11][12]

Citations:


Rename all test methods to the test... convention for PHPUnit 9.6 compatibility.

These new test methods use #[Test] attributes, which are incompatible with PHPUnit 9.6. The PHPUnit\Framework\Attributes classes were introduced in PHPUnit 10 and do not exist in 9.6, causing test discovery and execution to fail. Rename each method to follow test{MethodName} (e.g., testCleanAvatarRemovesValidAvatarFileUnderUploadRoot()) to maintain compatibility across the required 9.6–11.x range per coding guidelines.

🧰 Tools
🪛 PHPMD (2.15.0)

[warning] 372-372: Avoid variables with short names like $db. Configured minimum length is 3. (undefined)

(ShortVariable)


[error] 390-433: Remove error control operator '@' on line 431. (undefined)

(ErrorControlOperator)


[warning] 412-412: Avoid variables with short names like $db. Configured minimum length is 3. (undefined)

(ShortVariable)


[error] 436-465: Remove error control operator '@' on line 463. (undefined)

(ErrorControlOperator)


[warning] 452-452: Avoid variables with short names like $db. Configured minimum length is 3. (undefined)

(ShortVariable)


[warning] 470-470: Avoid variables with short names like $db. Configured minimum length is 3. (undefined)

(ShortVariable)


[warning] 485-485: Avoid variables with short names like $db. Configured minimum length is 3. (undefined)

(ShortVariable)


[warning] 504-504: Avoid variables with short names like $db. Configured minimum length is 3. (undefined)

(ShortVariable)


[error] 515-565: Remove error control operator '@' on line 560. (undefined)

(ErrorControlOperator)


[error] 515-565: Remove error control operator '@' on line 562. (undefined)

(ErrorControlOperator)


[warning] 537-537: Avoid variables with short names like $db. Configured minimum length is 3. (undefined)

(ShortVariable)


[warning] 579-579: Avoid variables with short names like $db. Configured minimum length is 3. (undefined)

(ShortVariable)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit/htdocs/modules/system/SystemMaintenanceTest.php` around lines 366
- 592, Rename each PHPUnit test method to the test... convention so PHPUnit 9.6
discovers them: change cleanAvatarRemovesValidAvatarFileUnderUploadRoot() →
testCleanAvatarRemovesValidAvatarFileUnderUploadRoot(),
cleanAvatarSkipsTraversalPathButStillDeletesDbRow() →
testCleanAvatarSkipsTraversalPathButStillDeletesDbRow(),
cleanAvatarSkipsAbsolutePathButStillDeletesDbRow() →
testCleanAvatarSkipsAbsolutePathButStillDeletesDbRow(),
cleanAvatarHandlesMissingFileAndStillDeletesDbRow() →
testCleanAvatarHandlesMissingFileAndStillDeletesDbRow(),
cleanAvatarHandlesEmptyAvatarFileAndStillDeletesDbRow() →
testCleanAvatarHandlesEmptyAvatarFileAndStillDeletesDbRow(),
cleanAvatarHandlesNullByteAvatarFileAndStillDeletesDbRow() →
testCleanAvatarHandlesNullByteAvatarFileAndStillDeletesDbRow(),
cleanAvatarSkipsNonAvatarsSubdirUnderUploadRoot() →
testCleanAvatarSkipsNonAvatarsSubdirUnderUploadRoot(), and
cleanAvatarNormalisesBackslashesInAvatarFile() →
testCleanAvatarNormalisesBackslashesInAvatarFile(); keep the existing #[Test]
attributes or remove them if you prefer, but the critical change is renaming the
methods so PHPUnit 9.6 recognizes the tests.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

Comment on lines +19 to 31
// execute outside a bootstrapped XOOPS context. Without this, the
// require_once below would fail with an "undefined constant" fatal
// when the file is hit via a direct URL, leaking server path details
// in the error message. Uses the project-standard one-liner shape
// for consistency with the rest of the codebase.
defined('XOOPS_ROOT_PATH') || exit('Restricted access');

// xoops_remove_file_quietly() lives in cp_functions.php; admin and install
// callers normally load it via cp_header.php / page_moduleinstaller.php,
// but require it explicitly here so SystemMaintenance is self-sufficient
// regardless of which context instantiates it.
require_once XOOPS_ROOT_PATH . '/include/cp_functions.php';

Comment on lines +331 to +335
/**
* Recursively remove the per-test scratch directory.
*/
private function removeScratchDir(string $scratchRel): void
{
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants